--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit ab1be8ea2d07929717586036430b5689fae65b85
Parents : 5d87aa3
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-16T21:35:12-05:00
feat(propagation-nodes): propagation node management with new UI elements and API integration
- Added functionality to request paths for preferred propagation nodes during sync.
- Introduced new Material Design icons for better visual representation of node states.
- Updated settings to allow transfer limits in megabytes, improving user experience.
- Enhanced localization support for new features in multiple languages.
- Improved tests to cover new propagation node functionalities and UI interactions.
Changes
14 files changed, 666 insertions(+), 76 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index b7f3615a..d40aee69 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -1343,6 +1343,10 @@ export default {
// request sync
try {
+ const preferredHash = this.config?.lxmf_preferred_propagation_node_destination_hash;
+ if (preferredHash) {
+ await window.api.post(`/api/v1/destination/${preferredHash}/request-path`);
+ }
await window.api.get("/api/v1/lxmf/propagation-node/sync");
} catch (e) {
const errorMessage = e.response?.data?.message ?? this.$t("app.sync_error_generic");
diff --git a/meshchatx/src/frontend/components/MaterialDesignIcon.vue b/meshchatx/src/frontend/components/MaterialDesignIcon.vue
index aeb2f093..5cb29327 100644
--- a/meshchatx/src/frontend/components/MaterialDesignIcon.vue
+++ b/meshchatx/src/frontend/components/MaterialDesignIcon.vue
@@ -19,6 +19,11 @@
<script>
import * as mdi from "@mdi/js";
+const MDI_ICON_ALIASES = {
+ mdiRoute: "mdiRoutes",
+ mdiEmailSendOutline: "mdiSendOutline",
+};
+
export default {
name: "MaterialDesignIcon",
props: {
@@ -58,7 +63,8 @@ export default {
}
const name = this.mdiIconName;
- const path = mdi[name];
+ const aliasName = MDI_ICON_ALIASES[name] || name;
+ const path = mdi[aliasName];
if (path) return path;
diff --git a/meshchatx/src/frontend/components/propagation-nodes/PropagationNodesPage.vue b/meshchatx/src/frontend/components/propagation-nodes/PropagationNodesPage.vue
index 7ff34579..0a343ecd 100644
--- a/meshchatx/src/frontend/components/propagation-nodes/PropagationNodesPage.vue
+++ b/meshchatx/src/frontend/components/propagation-nodes/PropagationNodesPage.vue
@@ -4,101 +4,217 @@
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gray-50 dark:bg-zinc-950">
<div class="px-4 py-4 border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
<div class="rounded-2xl border border-gray-200 dark:border-zinc-800 p-4">
- <div class="flex flex-col gap-3">
- <div class="flex flex-wrap items-center gap-2">
- <div class="font-semibold text-gray-900 dark:text-zinc-100">Hosted Propagation Node</div>
- <span
- v-if="localPropagationNode"
- class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
- :class="
- localPropagationNode.is_propagation_enabled
- ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
- : 'bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-zinc-300'
- "
+ <div class="flex items-start justify-between gap-3">
+ <button
+ type="button"
+ class="min-w-0 text-left"
+ @click="isLocalManagerCollapsed = !isLocalManagerCollapsed"
+ >
+ <div class="flex items-center gap-2 min-w-0">
+ <MaterialDesignIcon
+ :icon-name="isLocalManagerCollapsed ? 'chevron-right' : 'chevron-down'"
+ class="size-5 text-gray-500 dark:text-zinc-400 shrink-0"
+ />
+ <div class="font-semibold text-gray-900 dark:text-zinc-100 truncate">
+ Hosted Propagation Node
+ </div>
+ <span
+ v-if="localPropagationNode"
+ class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
+ :class="
+ localNodeIsRunning
+ ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
+ : 'bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-zinc-300'
+ "
+ >
+ {{ localNodeIsRunning ? "Running" : "Stopped" }}
+ </span>
+ <span
+ v-if="
+ localPropagationNode &&
+ config.lxmf_preferred_propagation_node_destination_hash ===
+ localPropagationNode.destination_hash
+ "
+ class="inline-flex items-center gap-1 rounded-full bg-blue-100 dark:bg-blue-900/30 px-2 py-0.5 text-xs font-semibold text-blue-700 dark:text-blue-300"
+ >
+ Preferred
+ </span>
+ </div>
+ </button>
+ <div class="flex items-center gap-2 shrink-0">
+ <button
+ type="button"
+ class="text-gray-500 dark:text-zinc-400 hover:text-blue-600 dark:hover:text-blue-400 disabled:opacity-40"
+ title="Announce now"
+ :disabled="!localPropagationNode"
+ @click="announceNow"
+ >
+ <MaterialDesignIcon icon-name="bullhorn" class="size-5" />
+ </button>
+ <button
+ v-if="!localNodeIsRunning"
+ type="button"
+ class="text-gray-500 dark:text-zinc-400 hover:text-emerald-600 dark:hover:text-emerald-400 disabled:opacity-40"
+ title="Start node"
+ :disabled="!localPropagationNode"
+ @click="startLocalPropagationNode"
>
- {{ localPropagationNode.is_propagation_enabled ? "Running" : "Stopped" }}
- </span>
- <span
- v-if="
- localPropagationNode &&
- config.lxmf_preferred_propagation_node_destination_hash ===
- localPropagationNode.destination_hash
- "
- class="inline-flex items-center gap-1 rounded-full bg-blue-100 dark:bg-blue-900/30 px-2 py-0.5 text-xs font-semibold text-blue-700 dark:text-blue-300"
+ <MaterialDesignIcon icon-name="play" class="size-5" />
+ </button>
+ <button
+ v-if="localNodeIsRunning"
+ type="button"
+ class="text-gray-500 dark:text-zinc-400 hover:text-amber-600 dark:hover:text-amber-400"
+ title="Restart node"
+ @click="restartLocalPropagationNode"
+ >
+ <MaterialDesignIcon icon-name="refresh" class="size-5" />
+ </button>
+ <button
+ v-if="localNodeIsRunning"
+ type="button"
+ class="text-gray-500 dark:text-zinc-400 hover:text-red-600 dark:hover:text-red-400"
+ title="Stop node"
+ @click="stopLocalPropagationNode"
>
- Preferred
- </span>
+ <MaterialDesignIcon icon-name="stop" class="size-5" />
+ </button>
</div>
+ </div>
+
+ <div v-if="!isLocalManagerCollapsed" class="mt-3 space-y-3">
<div
v-if="config.lxmf_local_propagation_node_address_hash"
class="text-xs font-mono text-gray-600 dark:text-zinc-400 break-all"
>
<{{ config.lxmf_local_propagation_node_address_hash }}>
</div>
+ <div class="text-xs text-gray-600 dark:text-zinc-400 flex items-center gap-2">
+ <template v-if="nodePathFor(config.lxmf_local_propagation_node_address_hash)">
+ <span>{{
+ formatPathLabel(nodePathFor(config.lxmf_local_propagation_node_address_hash))
+ }}</span>
+ </template>
+ <template v-else>
+ <span>No path yet</span>
+ </template>
+ <button
+ type="button"
+ class="text-gray-500 dark:text-zinc-400 hover:text-blue-600 dark:hover:text-blue-400 disabled:opacity-40"
+ title="Find path now"
+ :disabled="!config.lxmf_local_propagation_node_address_hash"
+ @click="requestPathForNode(config.lxmf_local_propagation_node_address_hash)"
+ >
+ <MaterialDesignIcon icon-name="map-marker-path" class="size-4" />
+ </button>
+ </div>
+ <label class="block text-xs text-gray-600 dark:text-zinc-400">
+ Display name
+ <div class="mt-1 flex items-center gap-2">
+ <input
+ v-model.trim="localNodeDisplayNameDraft"
+ type="text"
+ maxlength="64"
+ class="w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl px-3 py-2"
+ placeholder="Anonymous Peer"
+ @keydown.enter.prevent="saveLocalNodeDisplayName"
+ />
+ <button
+ type="button"
+ class="text-gray-500 dark:text-zinc-400 hover:text-emerald-600 dark:hover:text-emerald-400"
+ title="Save name"
+ @click="saveLocalNodeDisplayName"
+ >
+ <MaterialDesignIcon icon-name="check" class="size-5" />
+ </button>
+ <button
+ type="button"
+ class="text-gray-500 dark:text-zinc-400 hover:text-gray-800 dark:hover:text-zinc-100"
+ title="Reset to Anonymous"
+ @click="resetLocalNodeDisplayName"
+ >
+ <MaterialDesignIcon icon-name="restore" class="size-5" />
+ </button>
+ </div>
+ </label>
<div
- v-if="localPropagationNode?.local_node_stats"
+ v-if="localNodeStatsVisible"
class="text-xs text-gray-600 dark:text-zinc-400 flex flex-wrap gap-x-3 gap-y-1"
>
<span>{{ formatSeconds(localPropagationNode.local_node_stats.uptime_seconds) }} uptime</span>
<span>{{ localPropagationNode.local_node_stats.total_peers }} peers</span>
<span>{{ localPropagationNode.local_node_stats.messagestore_count }} messages stored</span>
+ <span>{{ localPropagationNode.local_node_stats.client_messages_received }} received</span>
<span>{{ localPropagationNode.local_node_stats.client_messages_served }} served</span>
+ <span>{{ formatStorageUsage(localPropagationNode.local_node_stats) }} storage</span>
+ <span>RX {{ formatByteSize(localPropagationNode.local_node_stats.rx_bytes) }}</span>
+ <span>TX {{ formatByteSize(localPropagationNode.local_node_stats.tx_bytes) }}</span>
</div>
+ <div v-else class="text-xs text-gray-500 dark:text-zinc-500">Node stats appear when running.</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<label class="text-xs text-gray-600 dark:text-zinc-400">
- Delivery transfer limit (bytes)
+ Delivery transfer limit (MB)
<input
- v-model.number="config.lxmf_delivery_transfer_limit_in_bytes"
+ v-model.number="deliveryLimitInputMb"
type="number"
- min="1000"
+ min="0.001"
+ step="0.01"
class="mt-1 w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl px-3 py-2"
@input="onDeliveryTransferLimitChange"
/>
+ <div class="mt-1 text-[11px] text-gray-500 dark:text-zinc-500">
+ {{ formatByteSize(config.lxmf_delivery_transfer_limit_in_bytes) }}
+ </div>
</label>
<label class="text-xs text-gray-600 dark:text-zinc-400">
- Propagation transfer limit (bytes)
+ Propagation transfer limit (MB)
<input
- v-model.number="config.lxmf_propagation_transfer_limit_in_bytes"
+ v-model.number="propagationLimitInputMb"
type="number"
- min="1000"
+ min="0.001"
+ step="0.01"
class="mt-1 w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl px-3 py-2"
@input="onPropagationTransferLimitChange"
/>
+ <div class="mt-1 text-[11px] text-gray-500 dark:text-zinc-500">
+ {{ formatByteSize(config.lxmf_propagation_transfer_limit_in_bytes) }}
+ </div>
</label>
<label class="text-xs text-gray-600 dark:text-zinc-400">
- Propagation sync limit (bytes)
+ Propagation sync limit (MB)
<input
- v-model.number="config.lxmf_propagation_sync_limit_in_bytes"
+ v-model.number="propagationSyncLimitInputMb"
type="number"
- min="1000"
+ min="0.001"
+ step="0.01"
class="mt-1 w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl px-3 py-2"
@input="onPropagationSyncLimitChange"
/>
+ <div class="mt-1 text-[11px] text-gray-500 dark:text-zinc-500">
+ {{ formatByteSize(config.lxmf_propagation_sync_limit_in_bytes) }}
+ </div>
</label>
</div>
+ <label class="block text-xs text-gray-600 dark:text-zinc-400">
+ Propagation stamp cost
+ <input
+ v-model.number="config.lxmf_propagation_node_stamp_cost"
+ type="number"
+ min="13"
+ max="254"
+ class="mt-1 w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl px-3 py-2"
+ @input="onPropagationStampCostChange"
+ />
+ </label>
<div class="flex flex-wrap gap-2">
<button
type="button"
- class="inline-flex items-center gap-x-1.5 rounded-xl bg-blue-600 hover:bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors"
+ class="inline-flex items-center gap-x-1.5 rounded-xl bg-blue-600 hover:bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors disabled:opacity-40"
:disabled="!localPropagationNode"
@click="useLocalPropagationNode"
>
Use Our Node
</button>
- <button
- type="button"
- class="inline-flex items-center gap-x-1.5 rounded-xl bg-emerald-600 hover:bg-emerald-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors"
- @click="restartLocalPropagationNode"
- >
- Restart Node
- </button>
- <button
- type="button"
- class="inline-flex items-center gap-x-1.5 rounded-xl bg-red-600 hover:bg-red-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors"
- @click="stopLocalPropagationNode"
- >
- Stop Node
- </button>
</div>
</div>
</div>
@@ -186,6 +302,22 @@
<div class="text-xs text-gray-500 dark:text-zinc-500 mt-1">
Announced {{ formatTimeAgo(propagationNode.updated_at) }}
</div>
+ <div class="text-xs text-gray-500 dark:text-zinc-500 mt-1 flex items-center gap-2">
+ <template v-if="nodePathFor(propagationNode.destination_hash)">
+ <span>{{ formatPathLabel(nodePathFor(propagationNode.destination_hash)) }}</span>
+ </template>
+ <template v-else>
+ <span>No path</span>
+ </template>
+ <button
+ type="button"
+ class="text-gray-500 dark:text-zinc-400 hover:text-blue-600 dark:hover:text-blue-400"
+ title="Find path now"
+ @click="requestPathForNode(propagationNode.destination_hash)"
+ >
+ <MaterialDesignIcon icon-name="map-marker-path" class="size-4" />
+ </button>
+ </div>
<div
v-if="propagationNode.local_node_stats"
class="text-xs text-gray-500 dark:text-zinc-500 mt-1 flex flex-wrap gap-x-3 gap-y-1"
@@ -193,7 +325,11 @@
<span>{{ formatSeconds(propagationNode.local_node_stats.uptime_seconds) }} uptime</span>
<span>{{ propagationNode.local_node_stats.total_peers }} peers</span>
<span>{{ propagationNode.local_node_stats.messagestore_count }} stored</span>
+ <span>{{ propagationNode.local_node_stats.client_messages_received }} received</span>
<span>{{ propagationNode.local_node_stats.client_messages_served }} served</span>
+ <span>{{ formatStorageUsage(propagationNode.local_node_stats) }} storage</span>
+ <span>RX {{ formatByteSize(propagationNode.local_node_stats.rx_bytes) }}</span>
+ <span>TX {{ formatByteSize(propagationNode.local_node_stats.tx_bytes) }}</span>
</div>
</div>
<div class="flex-shrink-0">
@@ -349,9 +485,13 @@
import Utils from "../../js/Utils";
import WebSocketConnection from "../../js/WebSocketConnection";
import ToastUtils from "../../js/ToastUtils";
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "PropagationNodesPage",
+ components: {
+ MaterialDesignIcon,
+ },
data() {
return {
searchTerm: "",
@@ -363,6 +503,7 @@ export default {
lxmf_delivery_transfer_limit_in_bytes: 1000 * 1000 * 10,
lxmf_propagation_transfer_limit_in_bytes: 1000 * 256,
lxmf_propagation_sync_limit_in_bytes: 1000 * 10240,
+ lxmf_propagation_node_stamp_cost: 16,
},
currentPage: 1,
itemsPerPage: 20,
@@ -370,13 +511,30 @@ export default {
deliveryLimit: null,
propagationLimit: null,
propagationSyncLimit: null,
+ propagationStampCost: null,
},
+ isLocalManagerCollapsed: false,
+ localNodeDisplayNameDraft: "",
+ deliveryLimitInputMb: 0,
+ propagationLimitInputMb: 0,
+ propagationSyncLimitInputMb: 0,
+ nodePathsByHash: {},
};
},
computed: {
localPropagationNode() {
return this.propagationNodes.find((node) => node.is_local_node) ?? null;
},
+ localNodeIsRunning() {
+ const running = this.localPropagationNode?.local_node_stats?.is_running;
+ if (typeof running === "boolean") {
+ return running;
+ }
+ return Boolean(this.localPropagationNode?.is_propagation_enabled);
+ },
+ localNodeStatsVisible() {
+ return Boolean(this.localPropagationNode?.local_node_stats && this.localNodeIsRunning);
+ },
searchedPropagationNodes() {
return this.propagationNodes.filter((propagationNode) => {
const search = this.searchTerm.toLowerCase();
@@ -485,6 +643,9 @@ export default {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
+ if (window.matchMedia && window.matchMedia("(max-width: 640px)").matches) {
+ this.isLocalManagerCollapsed = true;
+ }
this.getConfig();
this.loadPropagationNodes();
},
@@ -494,6 +655,7 @@ export default {
switch (json.type) {
case "config": {
this.config = json.config;
+ this.syncManagerInputsFromConfig();
break;
}
}
@@ -502,6 +664,7 @@ export default {
try {
const response = await window.api.get("/api/v1/config");
this.config = response.data.config;
+ this.syncManagerInputsFromConfig();
} catch (e) {
// do nothing if failed to load config
console.log(e);
@@ -511,9 +674,12 @@ export default {
try {
const response = await window.api.patch("/api/v1/config", config);
this.config = response.data.config;
+ this.syncManagerInputsFromConfig();
+ return true;
} catch (e) {
ToastUtils.error(this.$t("common.save_failed"));
console.log(e);
+ return false;
}
},
async loadPropagationNodes() {
@@ -524,6 +690,7 @@ export default {
},
});
this.propagationNodes = response.data.lxmf_propagation_nodes;
+ await this.refreshPriorityNodePaths();
} catch {
// do nothing if failed to load
}
@@ -532,6 +699,7 @@ export default {
await this.updateConfig({
lxmf_preferred_propagation_node_destination_hash: destination_hash,
});
+ await this.requestPathForNode(destination_hash);
},
async stopUsingPropagationNode() {
await this.updateConfig({
@@ -541,12 +709,14 @@ export default {
async useLocalPropagationNode() {
if (!this.localPropagationNode) return;
await this.usePropagationNode(this.localPropagationNode.destination_hash);
+ await this.requestPathForNode(this.localPropagationNode.destination_hash);
},
async restartLocalPropagationNode() {
try {
await window.api.post("/api/v1/lxmf/propagation-node/restart");
ToastUtils.success("Local propagation node restarted");
await Promise.all([this.getConfig(), this.loadPropagationNodes()]);
+ await this.refreshPriorityNodePaths();
} catch {
ToastUtils.error(this.$t("common.save_failed"));
}
@@ -556,15 +726,132 @@ export default {
await window.api.post("/api/v1/lxmf/propagation-node/stop");
ToastUtils.success("Local propagation node stopped");
await Promise.all([this.getConfig(), this.loadPropagationNodes()]);
+ await this.refreshPriorityNodePaths();
+ } catch {
+ ToastUtils.error(this.$t("common.save_failed"));
+ }
+ },
+ async startLocalPropagationNode() {
+ try {
+ const didUpdate = await this.updateConfig({ lxmf_local_propagation_node_enabled: true });
+ if (!didUpdate) {
+ return;
+ }
+ ToastUtils.success("Local propagation node started");
+ await Promise.all([this.getConfig(), this.loadPropagationNodes()]);
+ await this.refreshPriorityNodePaths();
+ } catch {
+ ToastUtils.error(this.$t("common.save_failed"));
+ }
+ },
+ async announceNow(showSuccessToast = true) {
+ try {
+ await window.api.get("/api/v1/announce");
+ if (showSuccessToast) {
+ ToastUtils.success("Announce triggered");
+ }
+ await this.loadPropagationNodes();
+ await this.refreshPriorityNodePaths();
+ } catch {
+ ToastUtils.error(this.$t("common.save_failed"));
+ }
+ },
+ async saveLocalNodeDisplayName() {
+ const nextName = (this.localNodeDisplayNameDraft || "").trim() || "Anonymous Peer";
+ try {
+ const didUpdate = await this.updateConfig({ display_name: nextName });
+ if (!didUpdate) {
+ return;
+ }
+ this.localNodeDisplayNameDraft = nextName;
+ await this.announceNow(false);
+ ToastUtils.success("Name saved and announced");
+ await this.loadPropagationNodes();
+ await this.refreshPriorityNodePaths();
} catch {
ToastUtils.error(this.$t("common.save_failed"));
}
},
+ async resetLocalNodeDisplayName() {
+ this.localNodeDisplayNameDraft = "Anonymous Peer";
+ await this.saveLocalNodeDisplayName();
+ },
+ syncManagerInputsFromConfig() {
+ const displayName = (this.config.display_name || "").trim();
+ this.localNodeDisplayNameDraft = displayName || "Anonymous Peer";
+ this.deliveryLimitInputMb = this.bytesToMb(this.config.lxmf_delivery_transfer_limit_in_bytes);
+ this.propagationLimitInputMb = this.bytesToMb(this.config.lxmf_propagation_transfer_limit_in_bytes);
+ this.propagationSyncLimitInputMb = this.bytesToMb(this.config.lxmf_propagation_sync_limit_in_bytes);
+ },
+ bytesToMb(value) {
+ const n = Number(value);
+ if (!Number.isFinite(n) || n <= 0) {
+ return 0;
+ }
+ return Math.max(0.001, Math.round((n / 1000000) * 1000) / 1000);
+ },
+ mbToBytes(value) {
+ const n = Number(value);
+ if (!Number.isFinite(n) || n <= 0) {
+ return 1000;
+ }
+ return Math.max(1000, Math.round(n * 1000000));
+ },
+ async refreshPriorityNodePaths() {
+ const hashes = new Set();
+ const localHash = this.config.lxmf_local_propagation_node_address_hash;
+ if (localHash) {
+ hashes.add(localHash);
+ }
+ const preferredHash = this.config.lxmf_preferred_propagation_node_destination_hash;
+ if (preferredHash) {
+ hashes.add(preferredHash);
+ }
+ for (const hash of hashes) {
+ await this.requestPathForNode(hash);
+ }
+ },
+ async requestPathForNode(destinationHash) {
+ const hash = (destinationHash || "").trim();
+ if (!hash) {
+ return;
+ }
+ try {
+ const response = await window.api.get(`/api/v1/destination/${hash}/path`, {
+ params: { request: "1", timeout: 4 },
+ });
+ this.nodePathsByHash = {
+ ...this.nodePathsByHash,
+ [hash]: response.data.path || null,
+ };
+ } catch {
+ this.nodePathsByHash = {
+ ...this.nodePathsByHash,
+ [hash]: null,
+ };
+ }
+ },
+ nodePathFor(destinationHash) {
+ const hash = (destinationHash || "").trim();
+ if (!hash) {
+ return null;
+ }
+ return this.nodePathsByHash[hash] || null;
+ },
+ formatPathLabel(path) {
+ if (!path) {
+ return "No path";
+ }
+ const hops = Number(path.hops);
+ const hopsText = Number.isFinite(hops) ? `${hops} ${hops === 1 ? "hop" : "hops"}` : "Unknown hops";
+ const iface = path.next_hop_interface || "unknown interface";
+ return `${hopsText} via ${iface}`;
+ },
onDeliveryTransferLimitChange() {
if (this.saveTimeouts.deliveryLimit) clearTimeout(this.saveTimeouts.deliveryLimit);
this.saveTimeouts.deliveryLimit = setTimeout(async () => {
await this.updateConfig({
- lxmf_delivery_transfer_limit_in_bytes: this.config.lxmf_delivery_transfer_limit_in_bytes,
+ lxmf_delivery_transfer_limit_in_bytes: this.mbToBytes(this.deliveryLimitInputMb),
});
}, 450);
},
@@ -572,7 +859,7 @@ export default {
if (this.saveTimeouts.propagationLimit) clearTimeout(this.saveTimeouts.propagationLimit);
this.saveTimeouts.propagationLimit = setTimeout(async () => {
await this.updateConfig({
- lxmf_propagation_transfer_limit_in_bytes: this.config.lxmf_propagation_transfer_limit_in_bytes,
+ lxmf_propagation_transfer_limit_in_bytes: this.mbToBytes(this.propagationLimitInputMb),
});
}, 450);
},
@@ -580,7 +867,22 @@ export default {
if (this.saveTimeouts.propagationSyncLimit) clearTimeout(this.saveTimeouts.propagationSyncLimit);
this.saveTimeouts.propagationSyncLimit = setTimeout(async () => {
await this.updateConfig({
- lxmf_propagation_sync_limit_in_bytes: this.config.lxmf_propagation_sync_limit_in_bytes,
+ lxmf_propagation_sync_limit_in_bytes: this.mbToBytes(this.propagationSyncLimitInputMb),
+ });
+ }, 450);
+ },
+ onPropagationStampCostChange() {
+ if (this.saveTimeouts.propagationStampCost) clearTimeout(this.saveTimeouts.propagationStampCost);
+ this.saveTimeouts.propagationStampCost = setTimeout(async () => {
+ let cost = Number(this.config.lxmf_propagation_node_stamp_cost);
+ if (!Number.isFinite(cost) || cost < 13) {
+ cost = 13;
+ } else if (cost > 254) {
+ cost = 254;
+ }
+ this.config.lxmf_propagation_node_stamp_cost = cost;
+ await this.updateConfig({
+ lxmf_propagation_node_stamp_cost: cost,
});
}, 450);
},
@@ -598,6 +900,25 @@ export default {
const days = Math.floor(hours / 24);
return `${days}d`;
},
+ formatByteSize(bytes) {
+ const value = Number(bytes);
+ if (!Number.isFinite(value) || value < 0) return "0 B";
+ if (value < 1000) return `${Math.round(value)} B`;
+ if (value < 1000 * 1000) return `${(value / 1000).toFixed(1)} KB`;
+ if (value < 1000 * 1000 * 1000) return `${(value / (1000 * 1000)).toFixed(2)} MB`;
+ return `${(value / (1000 * 1000 * 1000)).toFixed(2)} GB`;
+ },
+ formatStorageUsage(stats) {
+ if (!stats || typeof stats !== "object") {
+ return "0 B";
+ }
+ const used = this.formatByteSize(stats.messagestore_bytes);
+ const limitValue = Number(stats.messagestore_limit_bytes);
+ if (!Number.isFinite(limitValue) || limitValue <= 0) {
+ return used;
+ }
+ return `${used} / ${this.formatByteSize(limitValue)}`;
+ },
},
};
</script>
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index bed992e5..a518ccdf 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -1995,39 +1995,51 @@
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- Delivery transfer limit (bytes)
+ Delivery transfer limit (MB)
</div>
<input
- v-model.number="config.lxmf_delivery_transfer_limit_in_bytes"
+ v-model.number="lxmfDeliveryTransferLimitInputMb"
type="number"
- min="1000"
+ min="0.001"
+ step="0.01"
class="input-field"
@input="onLxmfDeliveryTransferLimitChange"
/>
+ <div class="text-xs text-gray-600 dark:text-gray-400">
+ {{ formatByteSize(config.lxmf_delivery_transfer_limit_in_bytes) }}
+ </div>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- Propagation transfer limit (bytes)
+ Propagation transfer limit (MB)
</div>
<input
- v-model.number="config.lxmf_propagation_transfer_limit_in_bytes"
+ v-model.number="lxmfPropagationTransferLimitInputMb"
type="number"
- min="1000"
+ min="0.001"
+ step="0.01"
class="input-field"
@input="onLxmfPropagationTransferLimitChange"
/>
+ <div class="text-xs text-gray-600 dark:text-gray-400">
+ {{ formatByteSize(config.lxmf_propagation_transfer_limit_in_bytes) }}
+ </div>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- Propagation sync limit (bytes)
+ Propagation sync limit (MB)
</div>
<input
- v-model.number="config.lxmf_propagation_sync_limit_in_bytes"
+ v-model.number="lxmfPropagationSyncLimitInputMb"
type="number"
- min="1000"
+ min="0.001"
+ step="0.01"
class="input-field"
@input="onLxmfPropagationSyncLimitChange"
/>
+ <div class="text-xs text-gray-600 dark:text-gray-400">
+ {{ formatByteSize(config.lxmf_propagation_sync_limit_in_bytes) }}
+ </div>
</div>
<div v-if="config.lxmf_local_propagation_node_enabled" class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
@@ -2236,6 +2248,9 @@ export default {
nomad_default_page_path: "/page/index.mu",
},
saveTimeouts: {},
+ lxmfDeliveryTransferLimitInputMb: 10,
+ lxmfPropagationTransferLimitInputMb: 0.256,
+ lxmfPropagationSyncLimitInputMb: 10.24,
lastRememberedInboundStampCost: 8,
shortcuts: [],
reloadingRns: false,
@@ -2544,6 +2559,7 @@ export default {
if (json.config) {
this.config = { ...this.config, ...json.config };
this.sanitizeColorConfigFields();
+ this.syncLxmfTransferLimitInputs();
}
break;
}
@@ -2575,6 +2591,7 @@ export default {
if (merged) {
this.config = merged;
normalizeConfigColors(this.config);
+ this.syncLxmfTransferLimitInputs();
const inbound = Number(this.config.lxmf_inbound_stamp_cost);
if (inbound > 0) {
this.lastRememberedInboundStampCost = Math.min(254, inbound);
@@ -2613,6 +2630,7 @@ export default {
const newConfig = await patchServerConfig(config, window.api);
this.config = newConfig;
normalizeConfigColors(this.config);
+ this.syncLxmfTransferLimitInputs();
if (label) {
ToastUtils.success(this.$t("app.setting_auto_saved", { label: this.$t(`app.${label}`) }));
}
@@ -2621,6 +2639,35 @@ export default {
console.log(e);
}
},
+ syncLxmfTransferLimitInputs() {
+ this.lxmfDeliveryTransferLimitInputMb = this.bytesToMb(this.config.lxmf_delivery_transfer_limit_in_bytes);
+ this.lxmfPropagationTransferLimitInputMb = this.bytesToMb(
+ this.config.lxmf_propagation_transfer_limit_in_bytes
+ );
+ this.lxmfPropagationSyncLimitInputMb = this.bytesToMb(this.config.lxmf_propagation_sync_limit_in_bytes);
+ },
+ bytesToMb(value) {
+ const n = Number(value);
+ if (!Number.isFinite(n) || n <= 0) {
+ return 0;
+ }
+ return Math.max(0.001, Math.round((n / 1000000) * 1000) / 1000);
+ },
+ mbToBytes(value) {
+ const n = Number(value);
+ if (!Number.isFinite(n) || n <= 0) {
+ return 1000;
+ }
+ return Math.max(1000, Math.round(n * 1000000));
+ },
+ formatByteSize(bytes) {
+ const value = Number(bytes);
+ if (!Number.isFinite(value) || value < 0) return "0 B";
+ if (value < 1000) return `${Math.round(value)} B`;
+ if (value < 1000 * 1000) return `${(value / 1000).toFixed(1)} KB`;
+ if (value < 1000 * 1000 * 1000) return `${(value / (1000 * 1000)).toFixed(2)} MB`;
+ return `${(value / (1000 * 1000 * 1000)).toFixed(2)} GB`;
+ },
sanitizeColorConfigFields() {
if (!this.config) return;
normalizeConfigColors(this.config);
@@ -2870,7 +2917,7 @@ export default {
}
this.saveTimeouts.delivery_transfer_limit = setTimeout(async () => {
await this.updateConfig({
- lxmf_delivery_transfer_limit_in_bytes: this.config.lxmf_delivery_transfer_limit_in_bytes,
+ lxmf_delivery_transfer_limit_in_bytes: this.mbToBytes(this.lxmfDeliveryTransferLimitInputMb),
});
}, 1000);
},
@@ -2880,7 +2927,7 @@ export default {
}
this.saveTimeouts.propagation_transfer_limit = setTimeout(async () => {
await this.updateConfig({
- lxmf_propagation_transfer_limit_in_bytes: this.config.lxmf_propagation_transfer_limit_in_bytes,
+ lxmf_propagation_transfer_limit_in_bytes: this.mbToBytes(this.lxmfPropagationTransferLimitInputMb),
});
}, 1000);
},
@@ -2890,7 +2937,7 @@ export default {
}
this.saveTimeouts.propagation_sync_limit = setTimeout(async () => {
await this.updateConfig({
- lxmf_propagation_sync_limit_in_bytes: this.config.lxmf_propagation_sync_limit_in_bytes,
+ lxmf_propagation_sync_limit_in_bytes: this.mbToBytes(this.lxmfPropagationSyncLimitInputMb),
});
}, 1000);
},
diff --git a/meshchatx/src/frontend/components/tools/ToolsPage.vue b/meshchatx/src/frontend/components/tools/ToolsPage.vue
index d79e223f..49e5da6a 100644
--- a/meshchatx/src/frontend/components/tools/ToolsPage.vue
+++ b/meshchatx/src/frontend/components/tools/ToolsPage.vue
@@ -193,6 +193,14 @@ export default {
titleKey: "tools.bots.title",
descriptionKey: "tools.bots.description",
},
+ {
+ name: "propagation-nodes",
+ route: { name: "propagation-nodes" },
+ icon: "mailbox",
+ iconBg: "tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200",
+ title: "Propagation Nodes",
+ description: "Manage preferred and local propagation nodes with live stats and path checks.",
+ },
{
name: "forwarder",
route: { name: "forwarder" },
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 06e52f14..518e3daf 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -25,7 +25,21 @@
"export_identity": "Identität exportieren",
"bot_deleted": "Bot erfolgreich gelöscht",
"failed_to_delete": "Bot konnte nicht gelöscht werden",
- "more_bots_coming": "Weitere Bots folgen in Kürze!"
+ "more_bots_coming": "Weitere Bots folgen in Kürze!",
+ "chat_with_bot": "Chat",
+ "lxmf_address": "LXMF-Adresse",
+ "last_announce": "Letzter Announce",
+ "never_announced": "Auf diesem Knoten noch nicht gesehen",
+ "address_pending": "Erst nach dem ersten Start verfügbar",
+ "status_running": "Läuft",
+ "status_stopped": "Gestoppt",
+ "force_announce": "Jetzt ankündigen",
+ "edit_name": "Namen bearbeiten",
+ "bot_renamed": "Name gespeichert",
+ "rename_failed": "Name konnte nicht gespeichert werden",
+ "name_required": "Namen eingeben",
+ "announce_triggered": "Ankündigung angefordert",
+ "announce_failed": "Ankündigung konnte nicht angefordert werden"
},
"app": {
"name": "Reticulum MeshChatX",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index a3db46d0..db6bba37 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -25,7 +25,21 @@
"export_identity": "Export Identity",
"bot_deleted": "Bot deleted successfully",
"failed_to_delete": "Failed to delete bot",
- "more_bots_coming": "More bots coming soon!"
+ "more_bots_coming": "More bots coming soon!",
+ "chat_with_bot": "Chat",
+ "lxmf_address": "LXMF address",
+ "last_announce": "Last announce",
+ "never_announced": "Not seen on this node yet",
+ "address_pending": "Not available until the bot has started once",
+ "status_running": "Running",
+ "status_stopped": "Stopped",
+ "force_announce": "Announce now",
+ "edit_name": "Edit name",
+ "bot_renamed": "Bot name saved",
+ "rename_failed": "Could not save name",
+ "name_required": "Enter a name",
+ "announce_triggered": "Announce requested",
+ "announce_failed": "Could not request announce"
},
"app": {
"name": "Reticulum MeshChatX",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index fc7113fb..8928ee22 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -25,7 +25,21 @@
"export_identity": "Esporta Identità",
"bot_deleted": "Bot eliminato con successo",
"failed_to_delete": "Impossibile eliminare il bot",
- "more_bots_coming": "Altri bot in arrivo!"
+ "more_bots_coming": "Altri bot in arrivo!",
+ "chat_with_bot": "Chat",
+ "lxmf_address": "Indirizzo LXMF",
+ "last_announce": "Ultimo announce",
+ "never_announced": "Non ancora visto su questo nodo",
+ "address_pending": "Disponibile dopo il primo avvio del bot",
+ "status_running": "In esecuzione",
+ "status_stopped": "Fermato",
+ "force_announce": "Annuncia ora",
+ "edit_name": "Modifica nome",
+ "bot_renamed": "Nome salvato",
+ "rename_failed": "Impossibile salvare il nome",
+ "name_required": "Inserisci un nome",
+ "announce_triggered": "Annuncio richiesto",
+ "announce_failed": "Impossibile richiedere l'annuncio"
},
"app": {
"name": "Reticulum MeshChatX",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index a8aec984..65f6b901 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -25,7 +25,21 @@
"export_identity": "Экспорт личности",
"bot_deleted": "Бот успешно удален",
"failed_to_delete": "Не удалось удалить бота",
- "more_bots_coming": "Скоро появятся новые боты!"
+ "more_bots_coming": "Скоро появятся новые боты!",
+ "chat_with_bot": "Чат",
+ "lxmf_address": "Адрес LXMF",
+ "last_announce": "Последний announce",
+ "never_announced": "На этом узле ещё не виден",
+ "address_pending": "Появится после первого запуска бота",
+ "status_running": "Запущен",
+ "status_stopped": "Остановлен",
+ "force_announce": "Объявить сейчас",
+ "edit_name": "Изменить имя",
+ "bot_renamed": "Имя бота сохранено",
+ "rename_failed": "Не удалось сохранить имя",
+ "name_required": "Введите имя",
+ "announce_triggered": "Запрос announce отправлен",
+ "announce_failed": "Не удалось запросить announce"
},
"app": {
"name": "Reticulum MeshChatX",
diff --git a/tests/backend/fixtures/http_api_routes.json b/tests/backend/fixtures/http_api_routes.json
index c0651ce1..4fa22ab8 100644
--- a/tests/backend/fixtures/http_api_routes.json
+++ b/tests/backend/fixtures/http_api_routes.json
@@ -64,6 +64,10 @@
"method": "DELETE",
"path": "/api/v1/blocked-destinations/{destination_hash}"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/announce"
+ },
{
"method": "POST",
"path": "/api/v1/bots/delete"
@@ -84,6 +88,10 @@
"method": "GET",
"path": "/api/v1/bots/status"
},
+ {
+ "method": "PATCH",
+ "path": "/api/v1/bots/update"
+ },
{
"method": "POST",
"path": "/api/v1/bots/stop"
diff --git a/tests/frontend/AppPropagationSync.test.js b/tests/frontend/AppPropagationSync.test.js
index 21197d20..4e9b993f 100644
--- a/tests/frontend/AppPropagationSync.test.js
+++ b/tests/frontend/AppPropagationSync.test.js
@@ -22,6 +22,9 @@ const syncingStates = [
function makeSyncContext(axiosMock, tOverrides = {}) {
return {
+ config: {
+ lxmf_preferred_propagation_node_destination_hash: "deadbeef",
+ },
propagationNodeStatus: null,
_propagationSyncPollTimer: null,
propagationSyncLiveToastMessage: App.methods.propagationSyncLiveToastMessage,
@@ -77,6 +80,7 @@ function makeSyncContext(axiosMock, tOverrides = {}) {
describe("App propagation sync", () => {
const axiosMock = {
get: vi.fn(),
+ post: vi.fn(),
};
beforeEach(() => {
@@ -91,6 +95,7 @@ describe("App propagation sync", () => {
});
it("shows detailed success toast with stored, confirmations and hidden counts", async () => {
+ axiosMock.post.mockResolvedValue({ data: { message: "ok" } });
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/lxmf/propagation-node/sync") {
return Promise.resolve({ data: { message: "Sync is starting" } });
@@ -122,10 +127,12 @@ describe("App propagation sync", () => {
expect(ToastUtils.success).toHaveBeenCalledWith(
"Sync complete. 8 messages received. (3 stored, 2 confirmations, 3 hidden)"
);
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/destination/deadbeef/request-path");
expect(ToastUtils.error).not.toHaveBeenCalled();
});
it("polls status while syncing and updates live loading toast", async () => {
+ axiosMock.post.mockResolvedValue({ data: { message: "ok" } });
let statusCalls = 0;
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/lxmf/propagation-node/sync") {
@@ -178,6 +185,7 @@ describe("App propagation sync", () => {
});
it("uses translated status in error toast when sync ends in a failure state", async () => {
+ axiosMock.post.mockResolvedValue({ data: { message: "ok" } });
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/lxmf/propagation-node/sync") {
return Promise.resolve({ data: { message: "Sync is starting" } });
diff --git a/tests/frontend/PropagationNodesPage.test.js b/tests/frontend/PropagationNodesPage.test.js
index d70bc8be..0d5b24bb 100644
--- a/tests/frontend/PropagationNodesPage.test.js
+++ b/tests/frontend/PropagationNodesPage.test.js
@@ -11,7 +11,9 @@ vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
describe("PropagationNodesPage", () => {
const axiosMock = {
+ get: vi.fn(),
post: vi.fn(),
+ patch: vi.fn(),
};
beforeEach(() => {
@@ -39,20 +41,42 @@ describe("PropagationNodesPage", () => {
const ctx = {
localPropagationNode: { destination_hash: "local-node" },
usePropagationNode: vi.fn(),
+ requestPathForNode: vi.fn(),
};
await PropagationNodesPage.methods.useLocalPropagationNode.call(ctx);
expect(ctx.usePropagationNode).toHaveBeenCalledWith("local-node");
+ expect(ctx.requestPathForNode).toHaveBeenCalledWith("local-node");
+ });
+
+ it("prefers runtime local node state for running indicator", () => {
+ const runningByStats = PropagationNodesPage.computed.localNodeIsRunning.call({
+ localPropagationNode: {
+ is_propagation_enabled: true,
+ local_node_stats: { is_running: false },
+ },
+ });
+ expect(runningByStats).toBe(false);
+ });
+
+ it("formats storage usage with limit when available", () => {
+ const ctx = {
+ formatByteSize: PropagationNodesPage.methods.formatByteSize,
+ };
+ const text = PropagationNodesPage.methods.formatStorageUsage.call(ctx, {
+ messagestore_bytes: 76500,
+ messagestore_limit_bytes: 10240000,
+ });
+ expect(text).toBe("76.5 KB / 10.24 MB");
});
it("debounces propagation transfer limit save", async () => {
const ctx = {
- config: {
- lxmf_propagation_transfer_limit_in_bytes: 123456,
- },
+ propagationLimitInputMb: 1.234,
saveTimeouts: {
propagationLimit: null,
},
+ mbToBytes: PropagationNodesPage.methods.mbToBytes,
updateConfig: vi.fn().mockResolvedValue(undefined),
};
@@ -61,7 +85,24 @@ describe("PropagationNodesPage", () => {
await vi.advanceTimersByTimeAsync(500);
expect(ctx.updateConfig).toHaveBeenCalledWith({
- lxmf_propagation_transfer_limit_in_bytes: 123456,
+ lxmf_propagation_transfer_limit_in_bytes: 1234000,
+ });
+ });
+
+ it("debounces propagation stamp cost save with bounds", async () => {
+ const ctx = {
+ config: {
+ lxmf_propagation_node_stamp_cost: 3,
+ },
+ saveTimeouts: {
+ propagationStampCost: null,
+ },
+ updateConfig: vi.fn().mockResolvedValue(undefined),
+ };
+ await PropagationNodesPage.methods.onPropagationStampCostChange.call(ctx);
+ await vi.advanceTimersByTimeAsync(500);
+ expect(ctx.updateConfig).toHaveBeenCalledWith({
+ lxmf_propagation_node_stamp_cost: 13,
});
});
@@ -70,6 +111,7 @@ describe("PropagationNodesPage", () => {
const ctx = {
getConfig: vi.fn().mockResolvedValue(undefined),
loadPropagationNodes: vi.fn().mockResolvedValue(undefined),
+ refreshPriorityNodePaths: vi.fn().mockResolvedValue(undefined),
$t: (k) => k,
};
@@ -80,4 +122,93 @@ describe("PropagationNodesPage", () => {
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/lxmf/propagation-node/restart");
expect(ToastUtils.success).toHaveBeenCalledTimes(2);
});
+
+ it("triggers announce via icon action", async () => {
+ axiosMock.get.mockResolvedValue({ data: {} });
+ const ctx = {
+ loadPropagationNodes: vi.fn().mockResolvedValue(undefined),
+ refreshPriorityNodePaths: vi.fn().mockResolvedValue(undefined),
+ $t: (k) => k,
+ };
+ await PropagationNodesPage.methods.announceNow.call(ctx);
+ expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/announce");
+ expect(ToastUtils.success).toHaveBeenCalledWith("Announce triggered");
+ });
+
+ it("resets local node display name to Anonymous Peer", async () => {
+ const ctx = {
+ localNodeDisplayNameDraft: "Custom Name",
+ saveLocalNodeDisplayName: vi.fn().mockResolvedValue(undefined),
+ };
+ await PropagationNodesPage.methods.resetLocalNodeDisplayName.call(ctx);
+ expect(ctx.localNodeDisplayNameDraft).toBe("Anonymous Peer");
+ expect(ctx.saveLocalNodeDisplayName).toHaveBeenCalledTimes(1);
+ });
+
+ it("uses collapsed manager on small screens", () => {
+ const originalMatchMedia = window.matchMedia;
+ window.matchMedia = vi.fn().mockReturnValue({ matches: true });
+ const ctx = {
+ isLocalManagerCollapsed: false,
+ getConfig: vi.fn(),
+ loadPropagationNodes: vi.fn(),
+ refreshPriorityNodePaths: vi.fn(),
+ };
+ PropagationNodesPage.mounted.call(ctx);
+ expect(ctx.isLocalManagerCollapsed).toBe(true);
+ window.matchMedia = originalMatchMedia;
+ });
+
+ it("saves local display name and announces immediately", async () => {
+ axiosMock.patch.mockResolvedValue({
+ data: {
+ config: {
+ display_name: "Friendly Node",
+ lxmf_delivery_transfer_limit_in_bytes: 10000000,
+ lxmf_propagation_transfer_limit_in_bytes: 256000,
+ lxmf_propagation_sync_limit_in_bytes: 10240000,
+ },
+ },
+ });
+ axiosMock.get.mockResolvedValue({ data: {} });
+
+ const ctx = {
+ localNodeDisplayNameDraft: " Friendly Node ",
+ config: {
+ lxmf_delivery_transfer_limit_in_bytes: 10000000,
+ lxmf_propagation_transfer_limit_in_bytes: 256000,
+ lxmf_propagation_sync_limit_in_bytes: 10240000,
+ },
+ syncManagerInputsFromConfig: vi.fn(),
+ loadPropagationNodes: vi.fn().mockResolvedValue(undefined),
+ refreshPriorityNodePaths: vi.fn().mockResolvedValue(undefined),
+ announceNow: PropagationNodesPage.methods.announceNow,
+ updateConfig: PropagationNodesPage.methods.updateConfig,
+ $t: (k) => k,
+ };
+
+ await PropagationNodesPage.methods.saveLocalNodeDisplayName.call(ctx);
+
+ expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/config", {
+ display_name: "Friendly Node",
+ });
+ expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/announce");
+ expect(ToastUtils.success).toHaveBeenCalledWith("Name saved and announced");
+ });
+
+ it("fetches path for a destination hash", async () => {
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ path: { hops: 2, next_hop_interface: "TCP Client" },
+ },
+ });
+ const ctx = {
+ nodePathsByHash: {},
+ };
+ await PropagationNodesPage.methods.requestPathForNode.call(ctx, "abcd");
+ expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/destination/abcd/path", {
+ params: { request: "1", timeout: 4 },
+ });
+ expect(ctx.nodePathsByHash.abcd).toEqual({ hops: 2, next_hop_interface: "TCP Client" });
+ });
});
diff --git a/tests/frontend/SettingsPage.config-persistence.test.js b/tests/frontend/SettingsPage.config-persistence.test.js
index 7bbb3a10..66de4396 100644
--- a/tests/frontend/SettingsPage.config-persistence.test.js
+++ b/tests/frontend/SettingsPage.config-persistence.test.js
@@ -258,21 +258,21 @@ describe("SettingsPage — config persistence (PATCH and related)", () => {
it("LXMF transfer/sync limits PATCH after debounce", async () => {
const w = await mountSettingsPage(api);
- w.vm.config.lxmf_delivery_transfer_limit_in_bytes = 9_000_000;
+ w.vm.lxmfDeliveryTransferLimitInputMb = 9;
await w.vm.onLxmfDeliveryTransferLimitChange();
await vi.advanceTimersByTimeAsync(1000);
expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
lxmf_delivery_transfer_limit_in_bytes: 9_000_000,
});
- w.vm.config.lxmf_propagation_transfer_limit_in_bytes = 300_000;
+ w.vm.lxmfPropagationTransferLimitInputMb = 0.3;
await w.vm.onLxmfPropagationTransferLimitChange();
await vi.advanceTimersByTimeAsync(1000);
expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
lxmf_propagation_transfer_limit_in_bytes: 300_000,
});
- w.vm.config.lxmf_propagation_sync_limit_in_bytes = 9_000_000;
+ w.vm.lxmfPropagationSyncLimitInputMb = 9;
await w.vm.onLxmfPropagationSyncLimitChange();
await vi.advanceTimersByTimeAsync(1000);
expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
diff --git a/tests/frontend/ToolsPage.test.js b/tests/frontend/ToolsPage.test.js
index af51ae5e..091b668e 100644
--- a/tests/frontend/ToolsPage.test.js
+++ b/tests/frontend/ToolsPage.test.js
@@ -15,6 +15,7 @@ describe("ToolsPage.vue", () => {
{ path: "/rnpath-trace", name: "rnpath-trace", component: { template: "div" } },
{ path: "/translator", name: "translator", component: { template: "div" } },
{ path: "/bots", name: "bots", component: { template: "div" } },
+ { path: "/propagation-nodes", name: "propagation-nodes", component: { template: "div" } },
{ path: "/forwarder", name: "forwarder", component: { template: "div" } },
{ path: "/documentation", name: "documentation", component: { template: "div" } },
{ path: "/micron-editor", name: "micron-editor", component: { template: "div" } },
@@ -51,7 +52,7 @@ describe("ToolsPage.vue", () => {
it("renders all tool rows", () => {
const wrapper = mountToolsPage();
const toolRows = wrapper.findAll(".tool-row");
- expect(toolRows.length).toBe(17);
+ expect(toolRows.length).toBe(18);
});
it("filters tools based on search query", async () => {
@@ -76,6 +77,6 @@ describe("ToolsPage.vue", () => {
await clearButton.trigger("click");
expect(wrapper.vm.searchQuery).toBe("");
- expect(wrapper.vm.filteredTools.length).toBe(17);
+ expect(wrapper.vm.filteredTools.length).toBe(18);
});
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────